Skip to content

parser: refuse a modifier-rescue and a not/nested-! command in an arg position, and require every clause terminator - #38

Merged
tannevaled merged 2 commits into
mainfrom
wt43-argvalue
Sep 26, 2026
Merged

tannevaled merged 2 commits into
mainfrom
wt43-argvalue

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

What this refuses, and why the gate is a table and not a corpus

v0.6.0 taught the parser that an arg_value position has no command
alternative. Five shapes of that family survived, reported against v0.6.0. The
bibliography turned them into four productions, one of which is not about
command at all, and the enumerated table grew from 76 shapes to 319.

shape v0.6.0 this branch MRI prism MRI parse.y
!!foo bar accepts refuses refuses refuses
begin; x; rescue foo bar; y; end accepts refuses refuses refuses
begin; x; rescue Foo, bar baz; y; end accepts refuses refuses refuses
f(x rescue nil) accepts refuses refuses refuses
f(1, x rescue nil) accepts refuses refuses refuses

The productions, mapped to our functions

Read from parse.y at ruby/ruby tag v3_4_0. Line numbers are that file's.

parse.y our function
expr: '!' command_call (3350) vs arg: '!' arg (4009) parseUnary, token.BANG arm
expr: keyword_not '\n'? expr (3345) — and no arg: keyword_not arm parseExprOrAssign, the not prefix
stmt: stmt modifier_rescue after_rescue stmt (3184) parseStatements → permitRescueMod(true)
arg_rhs: arg modifier_rescue after_rescue arg (4157-4166) parseAssignRhs, assignRhsValue → permitRescueMod(true)
arg_value: arg (4133), args (4314), call_args (4223), aref_args (4140) parseArgList / parseCallArgs / parseArrayElems → permitRescueMod(false)
command_args: call_args (4250) parseCommandArgs → permitRescueMod(false)
exc_list: arg_value (5950), case_args: arg_value (5397) parseSplatOrExpr
assoc: arg_value tASSOC arg_value | tLABEL arg_value | tDSTAR arg_value (6789) parseHashLiteral, parseKwArgValue
f_opt: f_arg_asgn f_eq arg_value parseParamDefault
expr_value (the if/unless/while/until/for/case headers) parseCond, parseCase's subject
opt_rescue: k_rescue exc_list exc_var then compstmt (5926), case_body: k_when case_args then compstmt (5397), k_if/k_unless/if_tail, expr_value_do, p_case_body the new clauseHeadEnd
arg: keyword_defined '\n'? begin_defined arg (4021) parseIdentExpr, the defined? arm

Four findings, and two of them contradict the brief I was given

1. There is no arg: arg modifier_rescue arg production. Line 4162 belongs to
arg_rhs, not arg. A modifier-rescue lives in exactly two families:
stmt (3184) and the assignment right-hand sides (arg_rhs 4162,
command_rhs 3323, mrhs_arg 3219, endless_arg 4081). So f(x rescue nil)
is not "a separate rule" from the arg_value family — it is the same rule,
and one flag expresses both. The carve-out is what makes the picture falsifiable:
[a = x rescue nil], f(a = 1 rescue nil), f(a.b = 1 rescue nil),
f(a ||= 1 rescue nil) and a[a = 1 rescue nil] are all legal MRI in the
very positions that refuse the bare form.

2. exc_list already had permitCommand(false); the defect was elsewhere.
parseSplatOrExpr has carried that call since v0.6.0, and it worked:
commandArgsFollow did decline to give foo the argument bar in
rescue foo bar. The leftover bar was then read as the clause's first
statement
, and the clause parsed. opt_rescue's then — term | keyword_then | term keyword_then — is not optional, and neither is case_body's, nor
k_if's, nor expr_value_do's. begin; x; rescue Foo end, case x; when 1 end,
if x end, while x end and for i in a end are all SyntaxErrors in MRI, and we
accepted every one of them. A refused production only surfaces as an error when
something downstream insists on what must come next.

3. The ! asymmetry is real and decides where the fix goes. !foo bar is
legal, !!foo bar is not, because expr: '!' command_call takes a
command_call — not an expr. A command_call is never itself a unary
expression, so the privilege cannot nest. The fix belongs at the operator
(one level of ! consumes it), not at the operand.

4. defined? unparenthesised is an arg production, and v0.6.0 refused it.
arg: keyword_defined '\n'? begin_defined arg (4021) is an arg, not a
command, so it is available in an arg position: [defined? x],
{k: defined? x}, 1 + defined? x, f(1, defined? x) and
def m(a = defined? x); end are legal MRI. v0.6.0 gated it behind
commandArgsFollow, whose first act is to refuse everything in an arg
position — a second regression of the same shape as the [defined?(foo bar)]
one v0.6.0 caught before shipping. Seven table shapes and two real stdlib
files
were refused because of it.

One mechanism, extended — not a second one

rescueModOK replaces noRescueMod with the sense inverted, so the zero value
is the refusing one
and only the positions MRI gives a production to turn it
on. permitRescueMod is its only writer and withRescueModifier its only
reader, exactly mirroring argOnly / permitCommand / commandArgsFollow. The
!, not and defined? fixes are all argOnly; they add no flag.

Gate: the enumerated table

319 shapes, each judged on ruby 4.0.5 by both ruby -c and
ruby --parser=parse.y -c. Any shape whose legality is contextual is written
inside the context that makes it legal, so no -c verdict here is a semantic
refusal dressed as a grammar one.

accepted → REFUSED (MRI refuses; the change) 115
REFUSED → accepted (MRI accepts; v0.6.0 refused — the defined? family) 7
regressions (MRI accepts, this branch refuses) 0
still accepted although MRI refuses (not fixed, below) 3
MRI's two parsers disagree → asserted in neither direction 13

A/B: the new table, dropped unchanged onto a clean origin/main checkout in a
separate worktree, fails 122 = 115 + 7 assertions and compiles.

The 13 disagreements are all not in a position that admits an expr: prism
accepts x = not y, f(not x), a[not x], p(not x), a.b(not x),
x ||= not y, return not x, yield not x, foo not x, p not x, !not x,
!not foo bar; parse.y refuses them. We keep prism's answer and assert
nothing. (a[foo bar do end] and f(foo bar do end), the two already known, are
likewise absent from both tables.)

The tables in arg_position_command_test.go are now 219 refused and 259
accepted shapes, extended in place.

Floor: two corpus sweeps, with their populations named

The sweeper is its own integrity guard and exits non-zero on any of: a row with
an empty path column, distinct paths ≠ input count, or fewer than
-min-ok files parsing — the floor being the baseline's own OK count, so a run
that read nothing cannot pass. All six runs printed INTEGRITY OK.

population files v0.6.0 parses this branch moved to NOT parsing
ruby/spec 87b1631, *_spec.rb under language/+core/ 2206 2205 2205 0
ruby/spec 87b1631, every .rb in the corpus 4413 4407 4407 0
MRI stdlib, rubylibdir recursive (ruby 4.0.5) 728 723 725 0

The two stdlib files that moved the other way are ripper/filter.rb and
ripper/lexer.rb, and they are the nicest result here: both use

if m = token_match(src, pattern)
then m.string(n)
else nil
end

— MRI's third spelling of then, term keyword_then. v0.6.0 tried
p.accept(token.THEN) before skipping the newline and left the then as a stray
statement. Implementing the clause terminator as the required nonterminal it
is refused 115 shapes and accepted two real files at the same time.

Controls, all still holding (re-measured against both MRI parsers)

Coverage, four parts

  1. Not compared as rounded totals: the below-100 function list is diffed.
  2. That list is identical — 64 entries, every one an ast/ast.go node()
    marker method. No parser.go function is below 100%.
  3. Each go tool cover -func was resolved in the tree that produced its
    profile
    (origin/main in its own worktree, the branch in its own).
  4. Full measured function set by name: 283 → 286, three additions
    (assignRhsValue, clauseHeadEnd, permitRescueMod) and zero removals,
    so nothing was orphaned out of the set.

gofmt -l . lists only paren_singleton_def_test.go, which it already listed on
origin/main; both files this branch touches are clean.

Not fixed (3 shapes MRI refuses and we still accept)

  • not a = foo bar — not's operand is an expr, and a = foo bar is
    command_asgn, reachable from stmt only. Needs the assignment RHS to lose
    its command right under not; a narrower question than this change.
  • begin; x; else; foo bar; end — an else with no rescue. A different rule
    (bodystmt), unrelated to arg_value.
  • f(&b, ) — a trailing comma after a block-pass argument. opt_block_arg is
    last in call_args and admits no comma after it.

🤖 Generated with Claude Code

tannevaled and others added 2 commits September 26, 2026 22:44
…d as a nested `!` operand

`arg` has no `modifier_rescue` alternative in parse.y v3_4_0 — only `stmt`
(3184) and the assignment right-hand sides `arg_rhs` (4162), `command_rhs` and
`endless_arg` do. So `f(x rescue nil)`, `[x rescue nil]`, `a[x rescue nil]`,
`{k: x rescue nil}`, `case x; when y rescue nil` and, because `expr` has no such
arm either, `if x rescue nil; end` are all SyntaxErrors in MRI, while
`f((x rescue nil))` and `f(a = 1 rescue nil)` are not.

`expr: '!' command_call` (3350) admits a paren-less command as the operand of
`!`, but the operand is a `command_call`, not an `expr`, so the privilege does
not nest: `!foo bar` parses and `!!foo bar` does not.

`opt_rescue` (5926) and `case_body` (5397) both REQUIRE the `then` nonterminal.
Without it the stray token of an `arg_value` we had already correctly refused —
the `bar` of `rescue foo bar` — was read as the clause's first statement and the
clause parsed anyway.

Mechanism: rescueModOK inverts noRescueMod so the zero value is the refusing
one, permitRescueMod is its only writer and withRescueModifier its only reader,
mirroring argOnly/permitCommand/commandArgsFollow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…and the clause terminator

- `defined?` unparenthesised is `arg: keyword_defined '\n'? begin_defined arg`
  (parse.y v3_4_0 4021) — an ARG production, so it must NOT go through
  commandArgsFollow, whose first act is to refuse everything in an `arg`
  position. `[defined? x]`, `{k: defined? x}`, `1 + defined? x` and
  `def m(a = defined? x); end` are legal MRI and v0.6.0 refused them.
- `arg` has no `keyword_not` arm (only `expr: keyword_not expr`, 3345), so
  `[not x]`, `{k: not x}`, `f(1, not x)`, `[*not x]`, `f(&not x)`,
  `def m(a = not x); end` and `case y; when not x; end` are SyntaxErrors in both
  of MRI's parsers.
- `then`/`do` — `term | KEYWORD | term KEYWORD` — is REQUIRED by every clause
  head (`opt_rescue` 5926, `case_body` 5397, `k_if`, `k_unless`, `expr_value_do`,
  `p_case_body`), so `if x end`, `while x end`, `for i in a end`,
  `case x; when 1 end` and `begin; x; rescue Foo end` are SyntaxErrors. Requiring
  it is also what makes a refused `arg_value` OBSERVABLE: the stray `bar` of
  `rescue foo bar` was being read as the clause's first statement.
- modifier-`rescue` refused in the remaining `arg_value` positions: hash literal
  entries, a `when`/`rescue` list, a parameter default, a keyword-argument value.

Tables: 319 enumerated shapes, each judged by BOTH `ruby -c` and
`ruby --parser=parse.y -c` on ruby 4.0.5. 115 accepted -> REFUSED, 7 REFUSED ->
accepted (the `defined?` family), 0 regressions, 13 shapes where MRI's two
parsers disagree and which are therefore asserted in neither direction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 8afa48e into main Sep 26, 2026
11 checks passed
@tannevaled
tannevaled deleted the wt43-argvalue branch September 26, 2026 21:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant